Experiment: User Taxonomies REST controller#77697
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
Size Change: -40 B (0%) Total Size: 7.82 MB 📦 View Changed
ℹ️ View Unchanged
|
|
Flaky tests detected in 1ed3533. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/25112694019
|
7f2a62a to
f479a6b
Compare
d4fecc6 to
16eaabc
Compare
16eaabc to
389df11
Compare
| // Empty config must serialize as `{}` to match the schema's | ||
| // `type: 'object'`. PHP encodes empty arrays as `[]`, so cast | ||
| // to stdClass for the empty case. | ||
| $data['config'] = empty( $config ) ? new stdClass() : $config; |
There was a problem hiding this comment.
This makes sense but feels a bit messy. Are we doing it anywhere else?
There was a problem hiding this comment.
We do that in other places like WP_REST_Attachments_Controller etc..
There was a problem hiding this comment.
Yep, makes sense.
The only other thing I wanted to confirm is: are we good with not handling this at the lower levels? Meaning, if we have [] somewhere in $config, should we make it an empty object as well, or are we good with empty arrays?
c7c4e45 to
8bb7623
Compare
Co-authored-by: Copilot <copilot@github.com>
8bb7623 to
e63c6ce
Compare
tyxla
left a comment
There was a problem hiding this comment.
I'm feeling good about this one. My comments are mostly nitpicks at this point, and potential follow-ups.
@jorgefilipecosta did you have any other concerns before we mark this as ready to ship?
| $values = array_values( array_filter( array_unique( $values ) ) ); | ||
|
|
||
| if ( ! empty( $values ) ) { | ||
| $meta_query = isset( $query_args['meta_query'] ) && is_array( $query_args['meta_query'] ) |
There was a problem hiding this comment.
The post metas are worth double-checking in any case - could be done after merging the PR. I can envision performance issues not coming from necessarily thousands of taxonomies, but rather the combination of:
- Number of taxonomies
- One post meta per taxonomy
- Post and post meta cache handling
Fine to have this as a follow-up, but I think we should note down a TODO item to make sure we're solid here.
| delete_post_meta( $post->ID, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY ); | ||
| foreach ( $values as $slug ) { | ||
| add_post_meta( $post->ID, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY, $slug ); | ||
| } |
There was a problem hiding this comment.
Likely another thing to consider double checking for performance issues
jorgefilipecosta
left a comment
There was a problem hiding this comment.
I only focused on sanitization during my review. It seems things work well, just left a minor comment for consideration but not a blocker.
tyxla
left a comment
There was a problem hiding this comment.
This is looking good from my perspective 👍
Left a few last-minute suggestions and questions, but nothing seems particularly blocking, we can always follow-up, especially considering that the endpoint is also still experimental.
| // Empty config must serialize as `{}` to match the schema's | ||
| // `type: 'object'`. PHP encodes empty arrays as `[]`, so cast | ||
| // to stdClass for the empty case. | ||
| $data['config'] = empty( $config ) ? new stdClass() : $config; |
There was a problem hiding this comment.
Yep, makes sense.
The only other thing I wanted to confirm is: are we good with not handling this at the lower levels? Meaning, if we have [] somewhere in $config, should we make it an empty object as well, or are we good with empty arrays?
Part of: #77600
What
Refactors the
wp_user_taxonomyREST surface and its write-time sanitization model. Stops exposing the rawpost_contentJSON string over REST and replaces it with a typedconfigobject plus a top-levelobject_typearray. Concentrates server-side declaration in one place: the controller class is the single source of truth for the schema, and a thinwp_insert_post_datafilter routes writes throughrest_sanitize_value_from_schema()against that schema. Adds filterable post-type membership via?object_type[]=and wires the correspondingisAnyfilter into the listing UI.How
The previous shape stored JSON in
post_contentand forced both client and consumers to parse strings. That made the typed schema (additionalProperties: false, label allowlist, types) impossible to enforce at the REST layer, made_fields=config.labels.singular_namequeries useless, and required the client to JSON-stringify on every save.The new shape pushes structure into the schema and centralizes write-time sanitization:
WP_REST_User_Taxonomies_Controller_Gutenberg) declares the canonical config schema via a staticget_config_schema()method, used both byget_item_schema()and by the post-type-scoped sanitizer. The schema closes over typed booleans, length-bounded label strings (maxLength: 200) and description (maxLength: 1000), withadditionalProperties: falseat every object level so unknown keys are rejected with 400.object_typecarriesmaxItems: 50and a^[a-z0-9_-]{1,20}$per-item pattern.gutenberg_user_taxonomy_sanitize_config) is a thin wrapper around WP core'srest_sanitize_value_from_schema()againstget_config_schema(). It applies one extra layer of HTML/control-char stripping (sanitize_text_fieldfor labels,sanitize_textarea_fieldfor description) becauserest_sanitize_value_from_schemaonly casts string types, it doesn't strip.wp_insert_post_data, scoped bypost_type === 'wp_user_taxonomy'.wp_insert_post_datais the last filter before the row is written, so the re-encode is what lands in the database. Stored JSON is encoded withJSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP, so the final stored bytes carry no live<,>, or&— any subsequent pass through kses (on later updates or display) sees an inert string. Empty object-shaped positions stay{}(not[]) via an explicitnormalize_config_for_encode()helper that casts them tostdClass, leaving room to add array-typed schema fields later withoutJSON_FORCE_OBJECTsilently coercing them.wp_insert_post()for the CPT is normalized to a canonical marker-only payload rather than persisted as-is — a hedge against a stray read path surfacing arbitrary bytes.unfiltered_html. Imports, XML-RPC, and REST writes all pass throughwp_insert_post()and therefore through the filter.isUserTaxonomyConfigJSON) is embedded in stored bytes. It's deliberately not in the REST schema (stripped on read), and isn't load-bearing today — kept as a forward-compat anchor for a content-only fallback sanitizer if a future write path bypasseswp_insert_post_data.Server changes
WP_REST_User_Taxonomies_Controller_Gutenberg. ExtendsWP_REST_Posts_Controllerbecause user taxonomies are stored as posts of the privatewp_user_taxonomyCPT, not as WP terms. Owns the schema (get_item_schema()consumesget_config_schema()andget_allowed_label_keys()— the canonical declaration site). Adds anobject_type[]collection param backed bymeta_query IN.prepare_item_for_databasevalidates slug shape (^[a-z0-9_-]{1,32}$) and uniqueness (returnsWP_Erroron collision) and JSON-encodes the requestconfigforpost_contentvianormalize_config_for_encode();configis replaced atomically when the param is present, with no in-configpartial-update support.create_itemandupdate_itemare overridden to write_wp_user_taxonomy_object_typepost meta and re-prepare the response so the just-written meta surfaces in the response body — same re-prepare pattern asWP_REST_Attachments_Controller.gutenberg_filter_user_taxonomy_post_contentonwp_insert_post_data. Acts onwp_user_taxonomyposts; falls through to a canonical empty payload for invalid JSON. Delegates structural sanitization toWP_REST_User_Taxonomies_Controller_Gutenberg::get_config_schema()viarest_sanitize_value_from_schema(), then re-encodes withJSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP.object_typemoved to multi-value post meta (_wp_user_taxonomy_object_type). Stored separately from the JSON config so listings can filter on it viameta_query(a single serialized array would force fragileLIKEqueries). Underscore-prefixed for protected-meta semantics. Surfaced over REST as the typed top-levelobject_typefield.Client changes
TaxonomyRecordandTaxonomyFormDataare now typed (config: StoredConfig,object_type: string[]).toFormData/serializeForSaveare thin translators between wire and form shape — no more JSON parse/stringify on every save.useObjectTypeFieldenables the post-types column filter (filterBy: { operators: [ 'isAny' ] }).routes/taxonomies/stage.tsxtranslates theobject_typeview filter into the REST query arg.Tests
PHPUnit coverage for the controller in
phpunit/experimental/content-types/class-wp-rest-user-taxonomies-controller-gutenberg-test.php— 28 tests / 88 assertions, including schema strictness on unknown keys (top-level +config.labels), marker key absent from the REST response, drafts skipped bygutenberg_register_user_defined_taxonomies, invalidobject_typeslug → 400, byte-stable round-trip, and the invalid-JSON normalization hedge.Notes
Taxonomies created before this PR aren't migrated. Their attached post types lived inside the JSON config in
post_content; the new code reads them from the_wp_user_taxonomy_object_typepost meta, which those older records don't have. To get them showing post types again in the UI, delete and recreate them. (The feature is still experimental and pre-launch, so no production data is at risk.)Testing instructions
post typesfilterUse of AI Tools
Opus 4.7 with direction, changes and review